Solving yet another EVM puzzle
Yet another EVM puzzle is a series of low-level EVM puzzles created by @mattaereal.
There are 5 different challenges. Each of them consists of a short sequence of EVM opcodes. The goal is to figure out the necessary calldata and value to send so that execution does not revert.
My solutions do not prioritize gas efficiency. But you could definitely leverage these challenges to sharpen your gas-golfing skills.
Index
Puzzle 1
The bytecode for the first puzzle is:
3415600736111760225760003560005236600034f080156022573115602357600080fd5b00
Let's start by disassembling it with the handy evm disasm
tool included in the Geth & Tools package.
$ echo "3415600736111760225760003560005236600034f080156022573115602357600080fd5b00" >> puzzle-1 && evm disasm puzzle-1
Outputs:
00: CALLVALUE
01: ISZERO
02: PUSH1 0x07
04: CALLDATASIZE
05: GT
06: OR
07: PUSH1 0x22
09: JUMPI
0a: PUSH1 0x00
0c: CALLDATALOAD
0d: PUSH1 0x00
0f: MSTORE
10: CALLDATASIZE
11: PUSH1 0x00
13: CALLVALUE
14: CREATE
15: DUP1
16: ISZERO
17: PUSH1 0x22
19: JUMPI
1a: BALANCE
1b: ISZERO
1c: PUSH1 0x23
1e: JUMPI
1f: PUSH1 0x00
21: DUP1
22: REVERT
23: JUMPDEST
24: STOP
To pass the challenge, execution must reach the STOP
instruction at position 24
. Avoiding at all costs running into the REVERT
at position 22
.
My approach was to first make sense out sections of the bytecode, without necessarily understanding what was going on with each individual opcode. I did it by noticing notable jumps in the bytecode. These act like if
statements that control the flow of the program.
The first notable section I noted was:
00: CALLVALUE
01: ISZERO
02: PUSH1 0x07
04: CALLDATASIZE
05: GT
06: OR
07: PUSH1 0x22
09: JUMPI
I could rewrite the above in pseudocode: if (callvalue == 0 || calldatasize > 7 bytes) go to 22
. Remember, position 22
is a REVERT
. These are the first two conditions I MUST comply with to pass the challenge:
- Send at least 1 wei.
- Not send more than 7 bytes of calldata.
Notably, after executing those instructions the stack is left empty.
Moving on to the next section:
0a: PUSH1 0x00
0c: CALLDATALOAD
0d: PUSH1 0x00
0f: MSTORE
10: CALLDATASIZE
11: PUSH1 0x00
13: CALLVALUE
14: CREATE
15: DUP1
16: ISZERO
17: PUSH1 0x22
19: JUMPI
Instructions from 0c
to 0f
store 32 bytes of calldata at position 0
in memory. Next, I there are 4 instructions ending with a CREATE
at position 14
. After following these instructions with pen and paper, I was able to rewrite the above in pseudocode like:
// store calldata in memory
memory[0x00:0x20] = calldata
// create a contract with whatever data is in `memory[0x00:calldatasize]` and sending `callvalue` amount
address = create(callvalue, 0, calldatasize)
It's simply creating a contract with some value and all calldata received. Remember that CREATE
pushes the address of the created contract on success. Otherwise pushes 0.
Then the purpose of the next set of instructions (from 1a
to 1e
) is to evaluate the balance of the address at the top of the stack (pushed by CREATE
).
1a: BALANCE
1b: ISZERO
1c: PUSH1 0x23
1e: JUMPI
From the above, one can tell that when the balance of the created account is zero, execution goes to position 23
. As seen below, that would lead execution straight to a STOP
.
1f: PUSH1 0x00
21: DUP1
22: REVERT
23: JUMPDEST
24: STOP
Let's recap:
- The call must send at least 1 wei.
- The call must not have more than 7 bytes of calldata.
- The calldata and value sent are used to create a contract.
- The created contract must have zero balance.
Seems contradicting. On the one hand I was forced to send some ETH in the creation of a contract. On the other hand it was requiring said contract to NOT have balance after creation.
Luckily, I had total control over 7 bytes of calldata. These were used as creation code of the contract. In other words, I had total control over the creation code of the contract.
I realized I could craft some creation code for a contract that ditches any ETH received. For instance, sending it to the zero address. Which can be done with SELFDESTRUCT
!
Thus the calldata can simply be two bytes: 3DFF
. 3D
(RETURNDATASIZE
) pushes a 0 to the stack, and FF
(SELFDESTRUCT
) sends any ETH in balance to the target address at the top of the stack (that is, the zero address).
Thus passing the challenge:
$ npx hardhat play
############
# Puzzle 1 #
############
? Enter the value to send: 1
? Enter the calldata: 0x3DFF
Puzzle solved!
Puzzle 2
The bytecode for the second puzzle is:
7faaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa60cc60005b90808253601001906001018060201160255750506000516000351814604957600080fd5b00
The entire disassembled code:
00: PUSH32 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
21: PUSH1 0xcc
23: PUSH1 0x00
25: JUMPDEST
26: SWAP1
27: DUP1
28: DUP3
29: MSTORE8
2a: PUSH1 0x10
2c: ADD
2d: SWAP1
2e: PUSH1 0x01
30: ADD
31: DUP1
32: PUSH1 0x20
34: GT
35: PUSH1 0x25
37: JUMPI
38: POP
39: POP
3a: PUSH1 0x00
3c: MLOAD
3d: PUSH1 0x00
3f: CALLDATALOAD
40: XOR
41: EQ
42: PUSH1 0x49
44: JUMPI
45: PUSH1 0x00
47: DUP1
48: REVERT
49: JUMPDEST
4a: STOP
I approached this puzzle with a "backtracking" approach. Once I spotted where I wanted to end (the STOP
at position 4a
), I started walking my way backwards to figure out the input that would get me there.
I had to reach the STOP
at position 4a
. The only possible way was to somehow jump from somewhere to position 49
. I realized there was one path that would take me there. See the PUSH1 0x49
followed by a JUMPI
at positions 42
and 44
.
Such jump would only be possible if the EQ
at position 41
left a 1
at the top of the stack. In other words, that the two elements compared by EQ
were equal.
I looked at the instructions before EQ
to detect the two elements that were compared. But at that point things got more complicated. There was a XOR
, and CALLDATALOAD
, and MLOAD
. And going a few steps beyond there appeared to be a loop (instructions at 35
and 37
force execution back to position 25
). Things weren't so trivial to analyze.
I stopped going backwards. I opened up the bytecode playground at evm.codes. I marked one breakpoint at position 38
, input some arbitrary calldata, and hit play.
Once execution paused, I could clearly see the machine's state. Three elements in the stack, and one in memory.
The next two POP
instructions would remove the first two stack elements. Thus leaving aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
at the top.
After a few more steps, the value ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc
was loaded from memory (due to MLOAD
at position 3c
), and calldata was loaded as well (due to CALLDATALOAD
at position 3f
). The resulting stack, right before executing XOR
, looked like:
The first two elements of the stack (calldata and ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc
) would get XOR'ed, and the result compared against aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
using EQ
. If they were equal, execution would finish successfuly.
What's the value that when XOR'ed with ccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc
results in aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
?
I asked Python:
hex(0xccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc ^ 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
'0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616'
Verifying the value 66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616
is correct:
hex(0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616 ^ 0xccdcecfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfc0c1c2c3c4c5c6c7c8c9cacbc)
'0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
That's it!
I used 0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616
as the calldata value to pass the challenge.
$ npx hardhat play
############
# Puzzle 2 #
############
? Enter the calldata:
0x66764656a6b68696e6f6c6d62636061666764656a6b68696e6f6c6d626360616
Puzzle solved!
Puzzle 3
The bytecode for the third puzzle is:
34156021576000356000523660006000f0600060006000346000945af1156026575b600080fd5b00
The disassembled code:
00: CALLVALUE
01: ISZERO
02: PUSH1 0x21
04: JUMPI
---
05: PUSH1 0x00
07: CALLDATALOAD
08: PUSH1 0x00
0a: MSTORE
---
0b: CALLDATASIZE
0c: PUSH1 0x00
0e: PUSH1 0x00
10: CREATE
---
11: PUSH1 0x00
13: PUSH1 0x00
15: PUSH1 0x00
17: CALLVALUE
18: PUSH1 0x00
1a: SWAP5
1b: GAS
1c: CALL
---
1d: ISZERO
1e: PUSH1 0x26
20: JUMPI
21: JUMPDEST
22: PUSH1 0x00
24: DUP1
25: REVERT
26: JUMPDEST
27: STOP
As you can see above, I first divided the disassembled code in 5 sections. I can summarize them as follows:
- If
CALLVALUE
is zero, execution reverts. - Stores calldata in memory.
- Creates a contract using provided calldata as creation code.
- Calls the created contract with no input.
- If call fails, execution reaches
STOP
.
Thus I needed to provide the creation code of a contract that could be succesfully created, though reverted when called.
One 3-bytes-long piece of EVM code that reverts when executed is 3D3DFD
. Where the two 3D
s push two zeros to the stack, and FD
triggers the revert, consuming the two pushed elements.
With that in mind, I now needed to craft the creation EVM code that would return such 3 bytes.
If I wanted to return something, then I knew my code would somehow need to reach a RETURN
(opcode F3
). RETURN
consumes two stack elements, which indicate memory offset and size of the returned data. Such data is read from memory, and thus must be somehow stored there before reaching the RETURN
. My data would be 3 bytes long: 3D3DFD
. One way to place that in memory is harcoding the bytes in the creation code, and then use CODECOPY
(opcode 39
). This instruction consumes 3 elements from stack: the memory offset, the calldata offset, and the size. Putting everything together, I came up with the following sequence of instructions:
00: PUSH1 0x03 --- size for CODECOPY
02: PUSH1 0x0d --- offset for CODECOPY
04: PUSH1 0x00 --- memory position for CODECOPY
06: CODECOPY
07: PUSH1 0x03 --- size for RETURN
09: PUSH1 0x00 --- memory position for RETURN
0b: RETURN
0c: STOP
0d: RETURNDATASIZE --- memory position for REVERT (same as PUSH1 0x00)
0e: RETURNDATASIZE --- memory position (same as PUSH1 0x00)
0f: REVERT
In raw bytecode: 6003600d60003960036000f3003d3dfd
.
Using these bytes as calldata and providing at least 1 wei in value, I was able to pass the challenge:
$ npx hardhat play
############
# Puzzle 3 #
############
? Enter the value to send: 1
? Enter the calldata: 0x6003600d60003960036000f3003d3dfd
Puzzle solved!
Puzzle 4
The bytecode for the fourth puzzle is:
60203611602157366000366020033736806020032060e01c63890d6908146026575b600080fd5b00
The disassembled code:
00: PUSH1 0x20
02: CALLDATASIZE
03: GT
04: PUSH1 0x21
06: JUMPI
07: CALLDATASIZE
08: PUSH1 0x00
0a: CALLDATASIZE
0b: PUSH1 0x20
0d: SUB
0e: CALLDATACOPY
0f: CALLDATASIZE
10: DUP1
11: PUSH1 0x20
13: SUB
14: KECCAK256
15: PUSH1 0xe0
17: SHR
18: PUSH4 0x890d6908
1d: EQ
1e: PUSH1 0x26
20: JUMPI
21: JUMPDEST
22: PUSH1 0x00
24: DUP1
25: REVERT
26: JUMPDEST
27: STOP
First I noticed these instructions:
00: PUSH1 0x20
02: CALLDATASIZE
03: GT
04: PUSH1 0x21
06: JUMPI
They're restricting the calldata's size to a max of 32 bytes. Otherwise execution would jump to position 21
and revert.
Then some weird shit happens between positions 07
and 20
. Some copying of calldata, some hashing, some bit-shifting... Not trivial stuff. Even so, it's not that difficult to get an idea of what's going on. Look at these instructions:
14: KECCAK256
15: PUSH1 0xe0
17: SHR
18: PUSH4 0x890d6908
1d: EQ
1e: PUSH1 0x26
20: JUMPI
The above means that:
- Something is hashed
- The result is shifted right 224 (0xe0) bits.
- The result is compared against
890d6908
. - If things are equal, jumps to position
26
and pass the challenge.
The challenge now boiled down to figuring out what on earth would have its first four bytes equal to 890d6908
when hashed.
My first (naive) thought was to code something in Golang to hash random stuff until I managed to get those first four bytes. I was already coding the script when a far simpler solution came to mind.
First four bytes, hashing, comparing, jumping... This resembled Solidity's function dispatcher! If I was right, then 890d6908
would play the part of the first four bytes of a function signature. Perhaps it's known ?
Yesss! 890d6908
is the function identifier of solve()
. Perhaps I could just send solve()
in the calldata, and pass the challenge ? It felt worth trying. Even if I still hadn't figured out what instructions from 07
to 13
meant.
I used Python to get the hex representation of the ASCII-encoded string "solve()".
"solve()".encode("ascii").hex()
'736f6c76652829'
Finally, I used those bytes as calldata:
$ npx hardhat play
############
# Puzzle 4 #
############
? Enter the calldata: 0x736f6c76652829
Puzzle solved!
Puzzle 5
The bytecode for the fifth puzzle is:
6004361160385760003560e01c6000600034f57f00000000000000000000000034d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d14603d575b600080fd5b00
The disassembled code:
00: PUSH1 0x04
02: CALLDATASIZE
03: GT
04: PUSH1 0x38
06: JUMPI
07: PUSH1 0x00
09: CALLDATALOAD
0a: PUSH1 0xe0
0c: SHR
0d: PUSH1 0x00
0f: PUSH1 0x00
11: CALLVALUE
12: CREATE2
13: PUSH32 0x00000000000000000000000034d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d
34: EQ
35: PUSH1 0x3d
37: JUMPI
38: JUMPDEST
39: PUSH1 0x00
3b: DUP1
3c: REVERT
3d: JUMPDEST
3e: STOP
I spent far more time on this one than I'd dare to admit.
From instructions 00
to 06
I could tell that the calldata's size couldn't be larger than 4 bytes.
Then, from instructions 07
to 12
it seemed a contract was created with CREATE2
, using the 4-bytes-long calldata as salt.
Then, the deployment address would be compared against 34d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d
. The challenge would only succeed as long as both addresses were equal.
Thus I arrived at the gist of the challenge. I needed to find the salt that would CREATE2
a contract with no code to address 0x34d5cbd8a2b5e1bb6952581ae65b47ed2bd5ef2d
.
Believe it or not, once again I went down the mining road. The idea was trying out pseudo-random salts with CREATE2
until I got that address. I left the script running for like 20 minutes. What a noob.
As I was taking a break with a cup of coffee, the answer popped in my mind. I honestly don't know how.
In CTFs, challenge prompts and names matter. Sometimes, a lot. They can give you useful hints to crack a challenge way faster.
Puzzle 5 is called "You never eat your stake alive".
"You never eat your stake alive". I needed a salt. Something I could give to CREATE2
to deploy to a specific address. My script clearly wouldn't work.
"You never eat your stake alive". Why would you even use that name anyway ?
"You never eat your stake alive".
"You never eat your stake alive". Your stake is dead when you eat it.
"You never eat your stake alive". Stake... Stake is beef, isn't it ?
"You never eat your stake alive". I need a word, something to use as salt. Once I find it, I'll need to write it in hex.
"You never eat your stake alive". A dead beef.
"You never eat your stake alive".
DEADBEEF!
If you attempt to solve this challenge in your local environment, 0xdeadbeef
is not considered a valid solution.
$ npx hardhat play
############
# Puzzle 5 #
############
? Enter the calldata: 0xdeadbeef
Wrong solution :(
But if you play it on the evm.codes playground, it works.
Below's a screenshot of my solution using 0xdeadbeef
as input.